﻿#region Using Statements
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework;
using System.Diagnostics;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Graphics.PackedVector;
using Microsoft.Xna.Framework.Content;
using Primitives3D;
#endregion // Using Statements

namespace ParticleSystem
{
    /// <summary>
    /// Object describing an entire system of particles.
    /// </summary>
    public class ParticleSystem
    {
        #region Fields
        /// <summary>
        /// The name of this particle system.
        /// </summary>
        private string mName;
        public string name
        {
            get { return mName; }
        }

        /// <summary>
        /// Flag for when the system is first starting up (so it can build up its particle count slowly).
        /// </summary>
        private bool mStartingUp;

        /// <summary>
        /// The tweakable settings for this particle system.
        /// </summary>
        private ParticleSettings mSettings;

        /// <summary>
        /// The particles currently in the system.
        /// </summary>
        private Dictionary<int, Particle> mParticles;

        /// <summary>
        /// List of particles to kill on each update.
        /// </summary>
        private List<int> mParticlesToKill;

        /// <summary>
        /// The next particle ID to assign when a particle is created.
        /// </summary>
        private int mNextParticleID;

        /// <summary>
        /// Texture used to render this particle.
        /// </summary>
        private Texture2D mParticleTexture;

        /// <summary>
        /// The graphics device on which the particle will be drawn.
        /// </summary>
        private GraphicsDevice mGraphicsDevice;

        /// <summary>
        /// The effect used to render particles as primitives.
        /// </summary>
        private BasicEffect mPrimitiveEffect;

        /// <summary>
        /// Vertex buffer containing data for rendering particles as primitives.
        /// </summary>
        private DynamicVertexBuffer mVertexBuffer;

        /// <summary>
        /// Index buffer containing data for rendering particles as primitives.
        /// </summary>
        private IndexBuffer mIndexBuffer;

        /// <summary>
        /// Tesselation for sphere particle primitives.
        /// </summary>
        private const int mTesselation = 3;

        /// <summary>
        /// Number of primitives per particle.
        /// </summary>
        private int mPrimitivesPerPartice;

        /// <summary>
        /// Number of vertices per particle.
        /// </summary>
        private int mVerticesPerParticle;

        /// <summary>
        /// Vertex data for all current particles in the system.
        /// </summary>
        List<VertexPositionNormalColorTexture> mVertices;

        /// <summary>
        /// Index data for all current particles in the system.
        /// </summary>
        List<ushort> mIndices;

        /// <summary>
        /// The content manager for this system.
        /// </summary>
        private ContentManager mContentMgr;

        #endregion // Fields

        #region Constructors and Destructors
        /// <summary>
        /// ParticleSystem constructor.
        /// </summary>
        /// <param name="name">The name of this particle system.</param>
        /// <param name="settings">The settings associated with this particle system.</param>
        /// <param name="graphicsDevice">The graphics device on which this particle system will be drawn.</param>
        /// <param name="contentMgr">The content manager this particle system should use for loading its content.</param>
        public ParticleSystem(string name, ParticleSettings settings, GraphicsDevice graphicsDevice, ContentManager contentMgr)
        {
            mName = name;
            mSettings = settings;
            mGraphicsDevice = graphicsDevice;
            mContentMgr = contentMgr;

            LoadContent();
            Initialize();          
        }

        /// <summary>
        /// Finalizer.
        /// </summary>
        ~ParticleSystem()
        {
            Dispose(false);
        }
        #endregion // Constructors and Destructors

        #region Loading and Initialization
        /// <summary>
        /// Load all content associated with this particle system.
        /// </summary>
        private void LoadContent()
        {
            mParticleTexture = mContentMgr.Load<Texture2D>(mSettings.textureName);

            mPrimitiveEffect = new BasicEffect(mGraphicsDevice);
            mPrimitiveEffect.EnableDefaultLighting();
            mPrimitiveEffect.VertexColorEnabled = true;
            mPrimitiveEffect.Texture = mParticleTexture;
            mPrimitiveEffect.TextureEnabled = true;       

            mVertices = new List<VertexPositionNormalColorTexture>();
            mIndices = new List<ushort>();

            // Create a vertex buffer large enough to hold the vertices for all possible particles in the system
            // each sphere particle will have n vertices where 
            //      n = (tesselation - 1) * ((tesselation * 2) + 1) + (2 * (tesselation * 2))
            mVerticesPerParticle = (mTesselation - 1) * ((mTesselation * 2) + 1) + (2 * (mTesselation * 2));
            // deliberately make the vertex buffer larger than necessary so we never deal with size issues
            mVertexBuffer = new DynamicVertexBuffer(mGraphicsDevice, typeof(VertexPositionNormalColorTexture), mVerticesPerParticle * (mSettings.maxParticles + 5), BufferUsage.WriteOnly);

            // Create an index buffer large enough to hold the indices for all possible particles in the system
            // each sphere particle will have n indices where
            //      n = ((tesselation * 2) * 3 * 2) + ((tesselation - 2) * (tesselation * 2) * 6)
            int indicesPerParticle = (mTesselation * 2 * 3 * 2) + ((mTesselation - 2) * (mTesselation * 2) * 6);
            mIndexBuffer = new IndexBuffer(mGraphicsDevice, typeof(ushort), indicesPerParticle * mSettings.maxParticles, BufferUsage.WriteOnly);
            
            // we can now calculate how many primitives there are per sphere.
            mPrimitivesPerPartice = indicesPerParticle / 3;

            // since the indices will never change, we can populate the index buffer data now
            int verticalSegments = mTesselation;
            int horizontalSegments = mTesselation * 2;
            for (int sphereIdx = 0; sphereIdx < mSettings.maxParticles; sphereIdx++)
            {
                int baseVertexOffset = mVerticesPerParticle * sphereIdx;

                // Create a fan connecting the bottom vertex to the bottom latitude ring.
                for (int i = 0; i < horizontalSegments; i++)
                {
                    AddIndex(baseVertexOffset + i); // bottom sphere vertex duplicate
                    AddIndex(baseVertexOffset + i + (horizontalSegments + 1));
                    AddIndex(baseVertexOffset + i + horizontalSegments);
                }

                // Fill the sphere body with triangles joining each pair of latitude rings.
                for (int i = 0; i < verticalSegments - 2; i++)
                {
                    for (int j = 0; j < horizontalSegments; j++)
                    {
                        int nextI = i + 1;
                        int nextJ = j + 1;

                        AddIndex(baseVertexOffset + horizontalSegments + i * (horizontalSegments + 1) + j);
                        AddIndex(baseVertexOffset + horizontalSegments + i * (horizontalSegments + 1) + nextJ);
                        AddIndex(baseVertexOffset + horizontalSegments + nextI * (horizontalSegments + 1) + j);

                        AddIndex(baseVertexOffset + horizontalSegments + i * (horizontalSegments + 1) + nextJ);
                        AddIndex(baseVertexOffset + horizontalSegments + nextI * (horizontalSegments + 1) + nextJ);
                        AddIndex(baseVertexOffset + horizontalSegments + nextI * (horizontalSegments + 1) + j);
                    }
                }

                // Create a fan connecting the top vertex to the top latitude ring.
                for (int i = 0; i < horizontalSegments; i++)
                {
                    AddIndex(baseVertexOffset + mVerticesPerParticle - 1 - i); // top sphere vertex duplicate
                    AddIndex(baseVertexOffset + mVerticesPerParticle - 2 - i - horizontalSegments);
                    AddIndex(baseVertexOffset + mVerticesPerParticle - 1 - i - horizontalSegments);
                }
            }

            mIndexBuffer.SetData(mIndices.ToArray());
        }

        /// <summary>
        /// Initialize the particle system.
        /// </summary>
        private void Initialize()
        {
            mStartingUp = true;

            mNextParticleID = 0;
            mParticles = new Dictionary<int, Particle>();
            mParticlesToKill = new List<int>();
        }
        #endregion // Loading and Initialization

        #region Disposal
        /// <summary>
        /// Frees resources used by this object.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// Frees resources used by this object.
        /// </summary>
        /// <param name="disposing">Whether or not we are disposing of this particle system.</param>
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (mVertexBuffer != null)
                    mVertexBuffer.Dispose();

                if (mIndexBuffer != null)
                    mIndexBuffer.Dispose();

                if (mPrimitiveEffect != null)
                    mPrimitiveEffect.Dispose();
            }
        }
        #endregion // Disposal

        #region Update and Draw
        /// <summary>
        /// Update the particle system.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        /// <param name="forces">The forces acting upon this particle system.</param>
        /// <param name="collisionPlanes">The planes in the world that particles may collide with.</param>
        public void Update(GameTime gameTime, List<Force> forces, List<DrawablePlane> collisionPlanes)
        {
            if (mStartingUp)
            {
                float avgLifetimeMillis = (float)(mSettings.lifespanRange.max.TotalMilliseconds - mSettings.lifespanRange.min.TotalMilliseconds) / 2;
                float particlesToCreatePerMS = ((float)mSettings.avgCount) / avgLifetimeMillis;
                float elapsedTimeMillis = (float)gameTime.ElapsedGameTime.TotalMilliseconds;
                int particlesToCreate = Math.Max(1, (int)(particlesToCreatePerMS * elapsedTimeMillis));

                for(int i = 0; i < particlesToCreate; i++)
                    AddParticle();

                // we have enough particles; we're not in start-up mode any more
                if (mParticles.Count >= mSettings.avgCount)
                    mStartingUp = false;
            }
            else
            {
                // calculate the number of particles we want on this update
                int numParticles = DesiredNumParticles();
                // we should create more particles
                if (numParticles > mParticles.Count)
                {
                    while (mParticles.Count < numParticles)
                    {
                        AddParticle();
                    }
                }
                // NOTE: we shouldn't ever explicitly delete particles that aren't ready to die; 
                // this will mess with their natural fading out as they reach the end of their lifespan.
                // so if the desired number of particles is less than the number we currently have,
                // just don't create any this frame and allow those that should naturally die to do so.
            }

            // wipe the list of particles to kill so we can start fresh on this update 
            mParticlesToKill.Clear();

            // update all particles in the system
            foreach (KeyValuePair<int, Particle> particlePair in mParticles)
            {
                // check if it's time for this particle to die
                if (particlePair.Value.age >= particlePair.Value.lifespan.TotalMilliseconds)
                {
                    mParticlesToKill.Add(particlePair.Key);
                }
                else
                {
                    // particle is still alive; update the particle
                    particlePair.Value.Update(gameTime, forces, collisionPlanes);
                }
            }

            // kill all the particles whose lifetimes have expired
            foreach (int particleIDToKill in mParticlesToKill)
            {
                KillParticle(particleIDToKill);
            }
        }

        /// <summary>
        /// Draw this particle system with sphere particles.
        /// </summary>
        /// <param name="worldMatrix">World matrix transformation.</param>
        /// <param name="viewMatrix">View matrix transformation.</param>
        /// <param name="projectionMatrix">Projection matrix transformation.</param>
        public void DrawPrimitives(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix)
        {
            // only bother drawing if we have at least one particle
            if (mParticles.Count > 0)
            {
                // Set BasicEffect parameters.
                mPrimitiveEffect.World = worldMatrix;
                mPrimitiveEffect.View = viewMatrix;
                mPrimitiveEffect.Projection = projectionMatrix;

                GraphicsDevice graphicsDevice = mPrimitiveEffect.GraphicsDevice;
                // save off original render states so we can reset them
                DepthStencilState origDepthStencilState = graphicsDevice.DepthStencilState;
                BlendState originalBlendState = graphicsDevice.BlendState;

                // Set renderstates.              
                graphicsDevice.DepthStencilState = DepthStencilState.DepthRead;
                graphicsDevice.BlendState = BlendState.AlphaBlend;

                mVertices.Clear();
                foreach (Particle particle in mParticles.Values)
                {
                    ConstructSphereVertices(particle);
                }

                // copy our vertex data into the vertex buffer
                int verticesCount = mParticles.Count * mVerticesPerParticle;
                mVertexBuffer.SetData(mVertices.ToArray(), 0, verticesCount, SetDataOptions.NoOverwrite);

                // Set our vertex declaration, vertex buffer, and index buffer.
                graphicsDevice.SetVertexBuffer(mVertexBuffer);
                graphicsDevice.Indices = mIndexBuffer;

                foreach (EffectPass effectPass in mPrimitiveEffect.CurrentTechnique.Passes)
                {
                    effectPass.Apply();
                    int primitiveCount = mParticles.Count * mPrimitivesPerPartice;
                    graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, verticesCount, 0, primitiveCount);
                }

                // now that we're done, reset the graphics device's render state
                graphicsDevice.DepthStencilState = origDepthStencilState;
                graphicsDevice.BlendState = originalBlendState;
            }
        }

        /// <summary>
        /// Draw this particle system with sprite particles.
        /// </summary>
        /// <param name="spriteBatch">The sprite batch with which the system's particles should be rendered.</param>
        /// <param name="worldMatrix">World matrix transformation.</param>
        /// <param name="viewMatrix">View matrix transformation.</param>
        /// <param name="projectionMatrix">Projection matrix transformation.</param>
        public void DrawSprites(SpriteBatch spriteBatch, Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix)
        {
            // possible solution to the sprite alpha transparency problem (currently not implemented):
            http://stackoverflow.com/questions/4835179/xna-layered-sprite-problem

            // draw all particles in the system
            foreach (Particle particle in mParticles.Values)
            {
                // calculate this particle's position in screen space
                Vector3 particlePos = mGraphicsDevice.Viewport.Project(particle.position, projectionMatrix, viewMatrix, worldMatrix);

                // only draw particles that are in front of us
                if (particlePos.Z < 1.0f)
                {
                    int xCoord = (int)(particlePos.X - particle.radius);
                    int yCoord = (int)(particlePos.Y - particle.radius);
                    int size = (int)(particle.radius * 2);
                    Color premultipliedColor = new Color(particle.color.R * particle.color.A,
                                                        particle.color.G * particle.color.A,
                                                        particle.color.B * particle.color.A);

                    spriteBatch.Draw(mParticleTexture, new Rectangle(xCoord, yCoord, size, size), premultipliedColor);
                }
            }
        }

        /// <summary>
        /// Draw this particle system.
        /// </summary>
        /// <param name="renderAsSprites">Flag indicating whether or not we should render with sprites.</param>
        /// <param name="spriteBatch">The sprite batch use to render the system.</param>
        /// <param name="worldMatrix">World matrix transformation.</param>
        /// <param name="viewMatrix">View matrix transformation.</param>
        /// <param name="projectionMatrix">Projection matrix transformation.</param>
        public void Draw(bool renderAsSprites, SpriteBatch spriteBatch, Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix)
        {
            if (renderAsSprites)
            {
                DrawSprites(spriteBatch, worldMatrix, viewMatrix, projectionMatrix);
            }
            else
            {
                DrawPrimitives(worldMatrix, viewMatrix, projectionMatrix);
            }
        }
        #endregion // Update and Draw

        #region Helpers for Primitive Vertices and Indices
        /// <summary>
        /// Add an index to our index buffer.
        /// </summary>
        /// <param name="index">The index to be added.</param>
        private void AddIndex(int index)
        {
            if (index > ushort.MaxValue)
                throw new ArgumentOutOfRangeException("index");

            mIndices.Add((ushort)index);
        }

        /// <summary>
        /// Add a vertex to our vertex buffer.
        /// </summary>
        /// <param name="position">The position of the vertex.</param>
        /// <param name="normal">The vertex's normal.</param>
        /// <param name="color">The vertex's color.</param>
        private void AddVertex(Vector3 position, Vector3 normal, Color color, Vector2 textureCoords)
        {
            mVertices.Add(new VertexPositionNormalColorTexture(position, normal, color, textureCoords));
        }

        /// <summary>
        /// Construct the vertices for a single particle as a sphere in our vertex buffer.
        /// </summary>
        /// <param name="particle">The particle for whom a sphere should be constructed in the vertex buffer.</param>
        private void ConstructSphereVertices(Particle particle)
        {
            int verticalSegments = mTesselation;
            int horizontalSegments = mTesselation * 2;

            float radius = particle.radius / 10.0f;
            Vector2 textureCoord; // variable to reuse in adding texture coordinates to the sphere vertices
            Color premultipliedColor = new Color(particle.color.R * particle.color.A,
                                                 particle.color.G * particle.color.A,
                                                 particle.color.B * particle.color.A);

            // Start with a single vertex at the bottom of the sphere duplicated once per horizontal segment.
            for (int i = 0; i < horizontalSegments; i++)
            {
                textureCoord.X = (float)i / ((float)horizontalSegments - 1); // U texture coordinate
                textureCoord.Y = 0; // V texture coordinate
                AddVertex((Vector3.Down * radius) + particle.position, Vector3.Down, premultipliedColor, textureCoord);
            }

            // Create rings of vertices at progressively higher latitudes.
            for (int i = 0; i < verticalSegments - 1; i++)
            {
                Vector3 vertexNormalToRepeat = Vector3.Zero;

                float latitude = ((i + 1) * MathHelper.Pi / verticalSegments) - MathHelper.PiOver2;

                float dy = (float)Math.Sin(latitude);
                float dxz = (float)Math.Cos(latitude);

                textureCoord.Y = (dy + 1) / 2; // V texture coordinate

                // Create a single ring of vertices at this latitude.
                for (int j = 0; j < horizontalSegments; j++)
                {
                    float longitude = j * MathHelper.TwoPi / horizontalSegments;

                    float dx = (float)Math.Cos(longitude) * dxz;
                    float dz = (float)Math.Sin(longitude) * dxz;

                    textureCoord.X = (float)j / (float)horizontalSegments; // U texture coordinate

                    Vector3 normal = new Vector3(dx, dy, dz);

                    AddVertex((normal * radius) + particle.position, normal, premultipliedColor, textureCoord);

                    // if this is the first vertex in this ring, we'll need to repeat it again at the end of the ring
                    if (j == 0)
                    {
                        vertexNormalToRepeat = normal;
                    }
                }

                textureCoord.X = 1; // U texture coordinate
                // Add the duplicated vertex at the end of the ring.
                AddVertex((vertexNormalToRepeat * radius) + particle.position, vertexNormalToRepeat, premultipliedColor, textureCoord);
            }

            // Finish with a single vertex at the top of the sphere duplicated once per horizontal segment.
            for (int i = 0; i < horizontalSegments; i++)
            {
                textureCoord.X = (float)i / ((float)horizontalSegments - 1); // U texture coordinate
                textureCoord.Y = 1; // V texture coordinate
                AddVertex((Vector3.Up * radius) + particle.position, Vector3.Up, premultipliedColor, textureCoord);
            }
        }
        #endregion // Helpers for Primitive Vertices and Indices

        #region Adding and Killing Particles
        /// <summary>
        /// Add a particle to the system.
        /// </summary>
        private void AddParticle()
        {
            TimeSpan lifespan = RandomParticleLifespan();
            // if we're just starting up the system, simulate varying the particles' ages by reducing their lifespans
            // this way all the particles don't die at the same time
            if (mStartingUp)
            {
                float lifespanReduction = Utility.randomFloat(0, (float)lifespan.TotalMilliseconds);
                lifespan = TimeSpan.FromMilliseconds(lifespan.TotalMilliseconds - lifespanReduction);
            }

            mParticles.Add(mNextParticleID, new Particle(mNextParticleID, lifespan, mSettings.emitterLocation, RandomParticleVelocity(), RandomParticleColor(), RandomParticleStartRadius(), RandomParticleEndRadius(), mSettings.collisionBehavior, mSettings.forces));
            mNextParticleID++;

            // if the next particle ID is the largest integer on the system, reset the counter.
            // (note that this assumes we'll never have this many particles alive at one time
            if (mNextParticleID == int.MaxValue)
            {
                mNextParticleID = 0;
            }
        }

        /// <summary>
        /// Delete a random particle from the system.
        /// </summary>
        private void KillRandomParticle()
        {
            // can't kill a particle if none exist
            if (mParticles.Count > 0)
            {
                // just kill the first particle in the list
                KillParticle(mParticles.FirstOrDefault().Key);
            }
        }

        /// <summary>
        /// Delete a specific particle from the system.
        /// </summary>
        /// <param name="id">The ID of the particle to be deleted.</param>
        private void KillParticle(int id)
        {
            if (mParticles.ContainsKey(id))
            {
                mParticles.Remove(id);
            }
        }
        #endregion // Adding and Killing Particles

        #region Particle Property Randomizers
        private Vector3 RandomParticleVelocity()
        {
            Vector3 velocity = Vector3.Zero;

            // add in random horizontal velocity component
            float horizVelocity = Utility.randomFloat(mSettings.horizontalVelocityRange.min, mSettings.horizontalVelocityRange.max);
            double horizAngle = Utility.random.NextDouble();

            velocity.X += horizVelocity * (float)Math.Cos(horizAngle);
            velocity.Z += horizVelocity * (float)Math.Sin(horizAngle);

            // add in random vertical velocity component
            velocity.Y += MathHelper.Lerp(mSettings.verticalVelocityRange.min, mSettings.verticalVelocityRange.max, Utility.randomFloat(0.0f, 1.0f));

            return velocity;
        }

        /// <summary>
        /// Generate a random particle lifespan within the allowed range of particle lifespans for this system.
        /// </summary>
        /// <returns>The randomly generated particle lifespan.</returns>
        private TimeSpan RandomParticleLifespan()
        {
            float lifespanMilliseconds = Utility.randomFloat((float)mSettings.lifespanRange.min.TotalMilliseconds, (float)mSettings.lifespanRange.max.TotalMilliseconds);
            return TimeSpan.FromMilliseconds(lifespanMilliseconds);
        }

        /// <summary>
        /// Generate a random particle start radius within the allowed range of particle start radii for this system.
        /// </summary>
        /// <returns>The randomly generated particle start radius.</returns>
        private float RandomParticleStartRadius()
        {
            return Utility.randomFloat(mSettings.startRadiusRange.min, mSettings.startRadiusRange.max);
        }

        /// <summary>
        /// Generate a random particle end radius within the allowed range of particle end radii for this system.
        /// </summary>
        /// <returns>The randomly generated particle end radius.</returns>
        private float RandomParticleEndRadius()
        {
            return Utility.randomFloat(mSettings.endRadiusRange.min, mSettings.endRadiusRange.max);
        }

        /// <summary>
        /// Generate a random particle color within the allowed range of particle colors for this system.
        /// </summary>
        /// <returns>The randomly generated particle color.</returns>
        private Color RandomParticleColor()
        {
            return new Color(Utility.randomInt((int)mSettings.colorRange.min.R, (int)mSettings.colorRange.max.R),
                             Utility.randomInt((int)mSettings.colorRange.min.G, (int)mSettings.colorRange.max.G),
                             Utility.randomInt((int)mSettings.colorRange.min.B, (int)mSettings.colorRange.max.B));
        }
        #endregion // Particle Property Randomizers

        /// <summary>
        /// Compute the desired number of particles for the system (which is a random variant of the average number of particles in the system).
        /// </summary>
        /// <returns>The randomly computed number of particles desired in the system.</returns>
        private int DesiredNumParticles()
        {
            int numParticles = (int)(mSettings.avgCount + Utility.randomFloat(-1.0f, 1.0f) * mSettings.countVariance);
            return Math.Min(mSettings.maxParticles, Math.Max(0, numParticles));
        }
    }
}
